延續 Day 26 的 MVP,今天把抽籤/點名器升級為進階版:
一鍵抽五個、復原上一抽、左側清單雙擊刪除,以及匯出中獎名單 CSV。
依然零相依,只用 Python 標準庫(Tkinter、csv、random、pathlib)。
今天多了什麼?
互動設計重點
程式碼(存成 raffle_gui_pro.py)
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import csv, random
from pathlib import Path
class RafflePro:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("抽籤/點名器 - 進階版")
# 狀態
self.pool = [] # 尚未抽到的名單
self.winners = [] # 已抽中的名單(依抽出順序)
self.undo_stack = [] # 可復原上一抽
self.rng = random.SystemRandom()
self._build_ui()
self._refresh_counts()
# ---------------- UI ----------------
def _build_ui(self):
main = ttk.Frame(self.root, padding=16); main.grid(sticky="nsew")
self.root.rowconfigure(0, weight=1); self.root.columnconfigure(0, weight=1)
main.columnconfigure(0, weight=1); main.columnconfigure(1, weight=1)
main.rowconfigure(2, weight=1)
# 上:控制列
top = ttk.Frame(main); top.grid(row=0, column=0, columnspan=2, sticky="we", pady=(0,8))
ttk.Button(top, text="匯入名單", command=self.import_list).grid(row=0, column=0, padx=4)
ttk.Button(top, text="匯出中獎", command=self.export_winners).grid(row=0, column=1, padx=4)
ttk.Button(top, text="重置", command=self.reset_all).grid(row=0, column=2, padx=4)
ttk.Button(top, text="復原上一抽", command=self.undo_last).grid(row=0, column=3, padx=4)
# 中:抽中顯示
display = ttk.Frame(main); display.grid(row=1, column=0, columnspan=2, sticky="we", pady=8)
display.columnconfigure(0, weight=1)
ttk.Label(display, text="抽中:", font=("Segoe UI", 11)).grid(row=0, column=0, sticky="w")
self.var_current = tk.StringVar(value="—")
ttk.Label(display, textvariable=self.var_current, font=("Segoe UI", 20)).grid(row=1, column=0, sticky="w")
# 左:候選(可雙擊刪除)
left = ttk.Frame(main); left.grid(row=2, column=0, sticky="nsew", padx=(0,8))
left.columnconfigure(0, weight=1); left.rowconfigure(1, weight=1)
ttk.Label(left, text="候選名單(雙擊刪除)").grid(row=0, column=0, sticky="w")
self.list_pool = tk.Listbox(left, height=12)
self.list_pool.grid(row=1, column=0, sticky="nsew")
self.list_pool.bind("<Double-1>", self._delete_selected_in_pool)
# 右:已抽中(最新在最上)
right = ttk.Frame(main); right.grid(row=2, column=1, sticky="nsew")
right.columnconfigure(0, weight=1); right.rowconfigure(1, weight=1)
ttk.Label(right, text="中獎名單(最新在最上方)").grid(row=0, column=0, sticky="w")
self.list_win = tk.Listbox(right, height=12)
self.list_win.grid(row=1, column=0, sticky="nsew")
# 下:新增/抽取/統計
bottom_left = ttk.Frame(main); bottom_left.grid(row=3, column=0, sticky="we", pady=(8,0))
ttk.Label(bottom_left, text="新增姓名").grid(row=0, column=0)
self.entry_new = ttk.Entry(bottom_left, width=18); self.entry_new.grid(row=0, column=1, padx=4)
ttk.Button(bottom_left, text="加入", command=self.add_name).grid(row=0, column=2, padx=4)
bottom_right = ttk.Frame(main); bottom_right.grid(row=3, column=1, sticky="we", pady=(8,0))
ttk.Button(bottom_right, text="抽一個 (Space/Enter)", command=self.draw_one).grid(row=0, column=0, padx=4)
ttk.Button(bottom_right, text="抽五個", command=lambda: self.draw_multi(5)).grid(row=0, column=1, padx=4)
self.lbl_counts = ttk.Label(bottom_right, text="剩餘:0|已抽:0")
self.lbl_counts.grid(row=0, column=2, sticky="e")
# 快捷鍵
self.root.bind("<space>", lambda e: self.draw_one())
self.root.bind("<Return>", lambda e: self.draw_one())
# ---------------- 核心功能 ----------------
def _refresh_counts(self):
self.lbl_counts.config(text=f"剩餘:{len(self.pool)}|已抽:{len(self.winners)}")
def _reload_pool_listbox(self):
self.list_pool.delete(0, tk.END)
for n in self.pool:
self.list_pool.insert(tk.END, n)
def import_list(self):
path = filedialog.askopenfilename(
title="匯入名單(TXT 或 CSV)",
filetypes=[("文字檔或 CSV", "*.txt *.csv"), ("所有檔案", "*.*")]
)
if not path: return
p = Path(path)
names = []
try:
if p.suffix.lower() == ".csv":
with p.open("r", encoding="utf-8-sig", newline="") as f:
for row in csv.reader(f):
if row and row[0].strip():
names.append(row[0].strip())
else:
with p.open("r", encoding="utf-8") as f:
for line in f:
name = line.strip()
if name:
names.append(name)
except Exception as e:
messagebox.showerror("匯入失敗", str(e)); return
# 去重:既有 pool/winners 都不可重複
existing = set(self.pool) | set(self.winners)
added = [n for n in names if n not in existing]
if not added:
messagebox.showinfo("提示", "沒有可新增的項目(可能全都重複)"); return
self.pool.extend(added)
self._reload_pool_listbox()
self._refresh_counts()
messagebox.showinfo("完成", f"已匯入 {len(added)} 筆(略過重複)")
def export_winners(self):
if not self.winners:
messagebox.showinfo("提示", "尚無中獎名單"); return
path = filedialog.asksaveasfilename(
title="匯出中獎名單",
defaultextension=".csv",
filetypes=[("CSV", "*.csv")]
)
if not path: return
try:
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["順序", "姓名"])
for i, name in enumerate(self.winners, 1):
w.writerow([i, name])
messagebox.showinfo("完成", f"已匯出 {len(self.winners)} 筆至:\n{path}")
except Exception as e:
messagebox.showerror("匯出失敗", str(e))
def add_name(self):
name = self.entry_new.get().strip()
if not name: return
if name in self.pool or name in self.winners:
messagebox.showwarning("重複", f"「{name}」已存在"); return
self.pool.append(name)
self.entry_new.delete(0, tk.END)
self._reload_pool_listbox()
self._refresh_counts()
def _delete_selected_in_pool(self, _event=None):
sel = self.list_pool.curselection()
if not sel: return
idx = sel[0]
name = self.list_pool.get(idx)
if messagebox.askyesno("刪除確認", f"從候選名單刪除「{name}」?"):
try:
self.pool.remove(name)
except ValueError:
pass
self._reload_pool_listbox()
self._refresh_counts()
def reset_all(self):
if messagebox.askyesno("重置", "清空中獎名單並回復所有候選?"):
self.pool += self.winners
self.winners.clear()
self.undo_stack.clear()
self.var_current.set("—")
self._reload_pool_listbox()
self.list_win.delete(0, tk.END)
self._refresh_counts()
def draw_one(self):
if not self.pool:
messagebox.showinfo("提示", "候選名單已抽完,請重置或新增成員"); return
# 從 pool 抽一個,移到 winners
idx = self.rng.randrange(len(self.pool))
name = self.pool.pop(idx)
self.winners.append(name)
self.undo_stack.append(name)
self.var_current.set(name)
# 更新 UI
self._reload_pool_listbox()
self.list_win.insert(0, name) # 最新在最上
self._refresh_counts()
self.root.bell()
def draw_multi(self, k: int):
for _ in range(k):
if not self.pool: break
self.draw_one()
def undo_last(self):
if not self.undo_stack:
messagebox.showinfo("提示", "沒有可復原的抽取"); return
name = self.undo_stack.pop()
# 從 winners 還原到 pool
try:
self.winners.remove(name)
except ValueError:
pass
self.pool.append(name)
self.var_current.set("—")
# 更新 UI:候選清單、已中清單刪除最上面一筆
self._reload_pool_listbox()
if self.list_win.size() > 0:
self.list_win.delete(0)
self._refresh_counts()
def main():
root = tk.Tk()
app = RafflePro(root)
root.mainloop()
if __name__ == "__main__":
main()
使用方式python raffle_gui_pro.py
操作建議
先按「匯入名單」選 TXT/CSV;或用「新增姓名」補幾筆測試。
按「抽一個」或鍵盤 Space/Enter。
若要快速抽多人,按「抽五個」。
抽錯人就按「復原上一抽」。
完成後按「匯出中獎」,得到含順序的 CSV。
若要重來:按「重置」,所有已抽者回到候選清單。
實作:

FAQ
1.匯入檔編碼?
建議 UTF-8 或 UTF-8 with BOM;程式對 CSV 使用 utf-8-sig 讀取以避免中文亂碼。
2.重複人名會怎樣?
匯入時會略過已存在的名字;手動新增也會檢查重複。
3.批次抽取會重複嗎?
不會。每次抽出即從候選池移除,直到抽完為止。
4.撤回後順序是否改變?
從中獎清單移除最後一次抽出的人,並回到候選池(在候選末尾,不影響抽籤公平性)。
5.可否改成一次抽 N 個?
把 draw_multi(5) 裡的 5 改成你想要的數字,或新增一個輸入框控制數量。